Skip to content

feat: design-pack registry — modules can contribute a site-wide look - #235

Merged
antosubash merged 3 commits into
mainfrom
feat/design-pack-registry
Aug 4, 2026
Merged

feat: design-pack registry — modules can contribute a site-wide look#235
antosubash merged 3 commits into
mainfrom
feat/design-pack-registry

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Adds the framework half of the design-pack feature. This is the piece
antosubash/simple_module_python_modules#1
is blocked on: its canopy_atlas module imports simple_module_core.design_packs,
which doesn't exist in any released or in-repo framework build, so the module
fails to load at boot and that repo's E2E suite goes red on the first test.

What a design pack is

A stylesheet a module ships that restyles the public site by overriding the
base design tokens beneath a <value>-root class. Before this, the only site
with a pack hard-coded it into pagebuilder — meaning a generic module shipped
one particular site's brand.

Core

  • simple_module_core.design_packsDesignPack(value, label) and
    DesignPackRegistry, sitting beside MenuRegistry / PermissionRegistry /
    PublicRouteRegistry. Both re-exported from the package root.
  • ModuleBase.register_design_packs(registry) — a new no-op-by-default hook.
  • Services gains a required design_packs slot.

Two deliberate strictnesses:

  • Slug shape. ^[a-z0-9][a-z0-9-]*$, validated in __post_init__ so an
    invalid pack can't be constructed at all. The slug becomes a CSS class
    fragment, so a module with a bad one fails at import rather than at render.
  • No silent overwrite. Registering a slug twice raises. Two packs sharing a
    root class would leave whichever stylesheet loaded last in charge — not
    something an administrator could diagnose from the UI.

Registering advertises a pack; it does not load the stylesheet. The CSS still
reaches the bundle via the host's styles.css. The registry exists so an
administrator can't select a pack nothing provides.

Hosting

create_app collects every module's packs in dependency order and publishes the
registry on app.state.design_packs (and app.state.sm.design_packs).

app_builder.py was sitting one line under the hard 300-line cap, so the
host-settings registration block moved to _phase_helpers.register_host_settings
— splitting by responsibility as CLAUDE.md prescribes rather than squeezing the
file. The hosting suite is byte-identical in outcome across that move
(180 passed / 2 skipped before and after).

Branding

The selected pack is a branding setting, not a per-page property — one site, one
look, read from shared props.

  • design_pack on settings, BrandingOut, BrandingUpdate; designPack in the
    shared-props payload and the BrandingShared TS type.
  • PUT /api/branding/ rejects a slug no installed module registered (422). The
    check lives in the endpoint because only there is app.state.design_packs
    reachable; the settings and DTO validators enforce shape alone.
  • A pack dropdown on the Manage page, fed by a designPacks page prop.

Shape and registration are checked in different places on purpose: settings are
hydrated from the DB at boot, so a pack whose module has since been uninstalled
degrades to an unstyled site rather than refusing to start. Only a live write is
rejected.

The branding view and endpoint both reach the registry via getattr, so this
published module still runs against a host older than the registry — it just
offers no packs to choose from.

Testing

Written test-first throughout — every test was watched failing before the code
that satisfies it.

  • framework/core/tests/test_design_packs.py — 23 tests: slug validation, frozen
    dataclass, registration order, copy-on-read, duplicate rejection, membership.
  • framework/core/tests/test_module_base.py — hook default no-op + override.
  • framework/hosting/tests/test_design_packs_wiring.py — the registry reaches
    app.state and a module's registered pack lands in it.
  • modules/branding/tests/test_design_pack.py — 16 tests across settings, DTO,
    payload, and the API's accept / reject / clear / leave-untouched behaviour.

Verified locally: make lint exit 0 (ruff format + ruff + ty + biome + per-workspace
tsc + 300-line cap + metadata/README checks), 1505 passed, 2 skipped on pytest,
41 passed on vitest.

make doctor reports SM020 (both users and keycloak auth providers installed
in the dev workspace) and two SM003 orphan pages in keycloak / audit_log
all pre-existing and untouched by this change.

After merge

Cutting the release is the maintainer's call. Once simple_module_core /
simple_module_hosting / simple_module_branding ship a version carrying this,
the modules repo can move its pins and PR #1's E2E suite unblocks.

A design pack is a stylesheet a module ships that restyles the public site
by overriding the base tokens beneath a `<value>-root` class. Modules had no
way to advertise one, so the only site with a pack hard-coded it into
pagebuilder — which meant pagebuilder shipped one particular site's brand.

`DesignPackRegistry` is the extension point, sitting beside MenuRegistry,
PermissionRegistry and PublicRouteRegistry. `create_app` collects every
module's packs in dependency order and publishes the registry on
`app.state.design_packs` (and `app.state.sm.design_packs`).

Two deliberate strictnesses:

- A slug must match `^[a-z0-9][a-z0-9-]*$`. It becomes a CSS class fragment,
  so anything else either fails to select or selects something unintended.
  Validated in `__post_init__`, so an invalid pack can't be constructed at
  all — a module with a bad slug fails at import, not at first render.
- Registering a slug twice raises rather than overwriting. Two packs sharing
  one root class would leave whichever stylesheet loaded last in charge,
  which is not diagnosable from the UI.

The registry advertises packs; it does not load stylesheets. A pack's CSS
still reaches the bundle through the host's `styles.css`. Its job is to stop
an administrator selecting a pack no installed module provides.

`app_builder.py` sat one line under the repo's hard 300-line cap, so the
host-settings registration block moves to `_phase_helpers.register_host_settings`
— splitting by responsibility as CLAUDE.md prescribes rather than squeezing
the file. The hosting suite is unchanged across that move (180 passed,
2 skipped before and after).

`Services` gains a required `design_packs` slot, keeping "one slot per owner"
honest; the two test construction sites are updated to match.

Claude-Session: https://claude.ai/code/session_01S5xZgDWnBt6EzWv5G8XbNG
The pack is a branding setting rather than a per-page property: one site has
one look, and the public page should read it from shared props instead of
digging into a page's root props.

- `BrandingSettings.design_pack` ("" = base tokens only), plus the field on
  `BrandingOut` / `BrandingUpdate` and `designPack` in the shared-props
  payload and the `BrandingShared` TS type.
- `PUT /api/branding/` rejects a slug no installed module registered. The
  check lives in the endpoint because only there is
  `request.app.state.design_packs` reachable; the settings and DTO validators
  enforce shape alone.
- The Manage page gets a pack dropdown, fed by a `designPacks` page prop from
  the branding view — the choices depend on which modules are installed, so
  they can't come from shared props.

Shape and registration are checked in different places on purpose. Settings
are hydrated from the DB at boot, so a pack whose module has since been
uninstalled must degrade to an unstyled site rather than refuse to start;
only a live write is rejected.

Both the view and the endpoint reach `app.state.design_packs` through
`getattr`, so this published module still runs against a host older than the
registry — it simply offers no packs to choose from.

`DesignPackField` lives under `components/`, not `pages/`, since anything
under `pages/` is treated as a real Inertia page (SM003).

Claude-Session: https://claude.ai/code/session_01S5xZgDWnBt6EzWv5G8XbNG
Adds the hook to the boot sequence in the lifecycle and overview docs, and a
"Design packs" section to framework-conventions beside "Public routes" —
covering what registering does and doesn't do (advertises a pack, does not
load its stylesheet), the slug rules, and why the selection is a branding
setting scoped to the public site.

Claude-Session: https://claude.ai/code/session_01S5xZgDWnBt6EzWv5G8XbNG
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7e05883
Status: ✅  Deploy successful!
Preview URL: https://ca97eeb4.simple-module-python.pages.dev
Branch Preview URL: https://feat-design-pack-registry.simple-module-python.pages.dev

View logs

@antosubash
antosubash merged commit fc506b3 into main Aug 4, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant